# (C) Numbler LLC 2006
# See LICENSE for details.


import hmac,sha,base64
from sslib.utils import alphaguid20,guidbin
from exc import *
from numbler.server.fdb import FDB
from twisted.internet import threads
from numbler import invite
import itertools
from _mysql_exceptions import IntegrityError
from numbler.server.localedb import NumblerLocale

#
# Utility functions
#
engine = None
sheet = None

def getSdb():
    global engine
    if not engine:
        import engine
    return engine.Engine.getInstance().ssdb    

def getsheet():
    global sheet
    if not sheet:
        import sheet

def dumper(arg):
    print 'deferred error occurred',arg

#
# Account lookup function to either resolve a principal or do
# actions when a principal is not available (yet)
#

def lookupAccount(userID,password):
    """ password is assumed to be in cleartext at this point """
    return getSdb().resolveAccount(userID,password)

def createAccount(userID,nick,password,locale,tz,existingSheets = None):

    return getSdb().createAccount(userID,nick,password,locale,tz,existingSheets)

def accountExists(userID):
    return getSdb().userNameExists(userID)

def resolveByStateToken(statetoken):
    bintoken = base64.b16decode(statetoken)
    if len(bintoken) != 20:
        raise AccountTokenNotExist
    return getSdb().resolveByStateToken(bintoken)

def requestPasswordChange(userID,urlroot):
    token = getSdb().requestPasswordChange(userID)
    resurl = base64.b16encode(token)
    # send mail to the recipient
    changeUrl = urlroot.child('changepass').child(resurl)
    threads.deferToThread(_sendPasswordChangeMail,userID,changeUrl).addErrback(dumper)

def _sendPasswordChangeMail(emailaddr,changeUrl):
    newmail = invite.resetPassword(emailaddr,changeUrl)
    print 'sending mail to ',emailaddr,changeUrl
    invite.sendMailInternal(newmail,emailaddr)

def lookupAccOnPasswordChange(token):
    bintoken = base64.b16decode(token)
    return getSdb().resolvePasswordChange(bintoken)

# sheet types
sheetTypes = {'public':0,'private':1,0:'public',1:'private'}

class Principal(object):
    """
    class for account and security information.

    A quick word on account states:

    0: account created but email not validated
    1: email validated
    2: email change pending -> needs validation.  on validation
    revert to 1
    
    """

    def __init__(self,acc_id,sheets,api_id = None,acc_key = None,locale = 'en_US',tz='CST'):
        """
        sheets must be a list of sheetIDs
        [sheet1,sheet2,etc]

        all properties can be used exte
        
        """
        # these properties can be used publicly
        self.acc_id = acc_id
        self.sheets = set([x[0] for x in sheets if x[0] is not None])
        self.ownedsheets = set([x[0] for x in sheets if x[1] == 1])

        self._setApiInfo(api_id,acc_key)

        # other properties
        self.userid = ''
        self.displayname = ''
        self.state = 0
        self.stateGUID = None
        self.locale = NumblerLocale(locale,tz)        

    def _setApiInfo(self,api_id,acc_key):
        self.api_id = api_id        
        # private to class
        self._secret_key = acc_key
        if self._secret_key:
            self._secret_key = base64.b16encode(self._secret_key)

    def setState(self,stateval,statetoken):
        self.state = stateval
        self.statetoken = statetoken

    def isAccountVerified(self):
        """
        check if an account is verified.  while this check should likely
        be sprinkled throughout the code base we are only going to use
        this when someone attempts to authenticate.
        """
        return self.state != 0

    def addInvitedSheet(self,sheetId):
        """
        called to add an invited sheet to an existing principal
        """
        self.sheets.update([sheetId])

    def removeInvitedSheet(self,sheetId):
        """
        called when *this* principal is removed from a sheet.  this method should
        not be called by the principal - instead use removeMemberInvite
        """
        if sheetId in self.sheets:
            self.sheets.remove(sheetId)
            #TODO: notification??

    def updateOwnedSheets(self,sheetId):
        """
        Update the sheets that are owned by the principal
        """
        self.sheets.update([sheetId])
        self.ownedsheets.update([sheetId])

    def __repr__(self):
        return '%s => %d,%s' % (self.userid,self.acc_id,self.api_id is not None and self.api_id or '')

    def verifymessage(self,hash,verb,uri,headers):
        """
        verify that a message was generated by the sender using HMAC-SHA1 verification
        
        verb = request.method
        uri = request.uri
        headers = request.received_headers
        
        >>> api,key = alphaguid20(),guidbin() 
        >>> p = Principal(1,[],api,key) 
        >>> headers = {'content-type': 'foo', 'x-numbler-date': 'today'}
        >>> headerstr = ''.join(['GET','\\n','\\n',headers['content-type'],'\\n',headers['x-numbler-date'],'\\n','/foo/bar'])
        >>> digest = hmac.new(p._secret_key,headerstr,sha).digest().strip() 
        >>> hash = base64.encodestring(digest)
        >>> p.verifymessage(hash,'GET','/foo/bar',headers)
        True
        
        """

        # optional headers
        contentmd5 = headers.get('content-md5')
        # check for required headers
        contentType = headers.get('content-type')
        numblerDate = headers.get('x-numbler-date')
        encodestr = ''.join([verb,'\n',
                             contentmd5 is not None and contentmd5 or '','\n',
                             contentType,'\n',
                             numblerDate,'\n',
                             uri])

        digest = hmac.new(self._secret_key,encodestr,sha).digest()

        # return if the message is valid
        if not base64.decodestring(hash) == digest:
            raise InvalidSignature(hash,base64.encodestring(digest).strip())
        return True

    def inviteToSheet(self,sheetId,users,rootURL):
        """
        Invite a user by email to join your spreadsheet

        users should be a set of email addresses.
        """

        getsheet()
        
        if sheetId not in self.ownedsheets:
            raise AccountAuthFailure(self,sheetId)

        print 'inviteToSheet',self.userid,self.displayname,users
        if self.userid in users:
            raise SelfInviteException()

        try:
            pending,existing = getSdb().addInvite(self,sheetId,users)
        except IntegrityError:
            raise DuplicateInviteException()
        sheetUid = getSdb().getSheetUid(sheetId)
        # get the sheet type
        id,stype,id_acc = getSdb().getSheetId(sheetUid)
        sht = sheet.Sheet.getInstance(sheetUid)
        
        d = threads.deferToThread(self._processInvites,users - pending,pending,sheetUid,sht.getAlias(),rootURL,stype)
        d.addErrback(dumper)
        
        return set([unicode(x,'utf-8') for x in pending]),existing


    def _processInvites(self,existing,pending,sheetUid,name,rootURL,stype):
        """
        generate email invites for the existing and the pending users.  this runs
        in a seperate thread and shouldn't use any thing dependent on the the rest of the system.
        """
        url = rootURL.child(sheetUid)

        sheetKind = sheetTypes[stype]
        print 'processInvites:',sheetKind,stype,existing,pending

        if sheetKind == 'public':
            # send the same public invite to everyone
            newmail = invite.inviteEmail(self.displayname,url,name)
            for emailaddr in itertools.chain(existing,pending):
                invite.sendMailInternal(newmail,emailaddr)
        else:
            # send notification emails to existing users
            if len(existing):
                newmail = invite.inviteEmailExistingUser(self.displayname,url,name)
                for emailaddr in existing:
                    invite.sendMailInternal(newmail,emailaddr)
            # send join emails to new users
            if len(pending):
                for emailaddr in pending:
                    newmail = invite.inviteEmailNewUser(self.displayname,url,name,emailaddr)
                    invite.sendMailInternal(newmail,emailaddr)                


    def revokeInvite(self,sheetId,users):
        if sheetId not in self.ownedsheets:
            raise AccountAuthFailure(self,sheetId)

        getSdb().revokeInvite(self,sheetId,users)

    def renameSheet(self,sheetId,alias):
        getsheet()
        if sheetId not in self.ownedsheets:
            raise AccountAuthFailure(self,sheetId)
        
        sheetUid = getSdb().getSheetUid(sheetId)
        sheet.Sheet.getInstance(sheetUid).setAlias(alias,self)
        
    def changePasswd(self,passwd):
        """
        change the account's passwd. The password is assumed to be in clear text at this point.
        """
        getSdb().updatePassword(self,passwd)

    def registerForApiAccount(self):
        """
        create an API ID and key for this account.
        """
        if self.api_id:
            return False
        id,key,d = getSdb().createApiIdAndKey(self)
        self._setApiInfo(id,key)
        return d

    def sendApiDetailsEmail(self):
        d = threads.deferToThread(self.sendApiDetailsEmailInternal)
        d.addErrback(dumper)
        return d

    def sendApiDetailsEmailInternal(self):
        """
        send an email to confirm the new account
        """
        newmail = invite.ApiKeyMail(self.api_id,self._secret_key)
        invite.sendMailInternal(newmail,self.userid)
        
    def checkCanAccess(self,sheetUid):
        # convert sheetUid to sheetid
        id = getSdb().getSheetId(sheetUid)[0]
        if id not in self.sheets:
            raise AccessDenied(sheetUid)

    def changeSheetType(self,sheetId,stype):
        sheetUid = getSdb().getSheetUid(sheetId)        
        if stype == 0: #  public
            getSdb().modifySheetType(sheetUid,0)
        elif stype == 1: # private
            getSdb().modifySheetType(sheetUid,1)

        getsheet()
        sht = sheet.Sheet.getInstanceFromCache(sheetUid)
        if sht:
            sht.setAuthType(stype)


    def makeSheetPublic(self,sheetUid):
        id = getSdb().getSheetId(sheetUid)[0]
        if id not in self.ownedsheets:
            raise AccessDenied(sheetUid)
        getSdb().modifySheetType(sheetUid,0)
        

    def makeSheetPrivate(self,sheetUid):
        id  = getSdb().getSheetId(sheetUid)[0]
        if id not in self.ownedsheets:
            raise AccessDenied(sheetUid)
        getSdb().modifySheetType(sheetUid,1)


    def getSheetSummary(self):
        """
        get a roll-up summary of all of the sheets that are owned by this user.

        details: name,private|public, # of active invites, # of invites pending
        
        """
        d = getSdb().getAccountSheetSummary(self)
        d.addCallback(self.processSheetSummary)
        d.addErrback(dumper)
        return d
        
    def processSheetSummary(self,results):
        def formatresults(res):
            return {u'sheetId':res[0],
                    u'name':unicode(res[1],'utf-8'),
                    u'type':res[2],
                    u'numInvites':res[3],
                    u'pendingInvites':res[4],
                    u'sheetUid':unicode(res[5],'utf-8'),
                    u'owner':res[6]}
        
        return (formatresults(res) for res in results if res[6] == 1),(formatresults(res) for res in results if res[6] == 0)    

    def getUserList(self,sheetId):
        """
        return a list of users on a sheet.  make sure everything is
        unicode
        """

        d = getSdb().getUserListForSheet(sheetId)
        d.addCallback(self.processUserList)
        return d

    def processUserList(self,results):
        return [{u'username':unicode(x[0],'utf-8'),u'dispname':unicode(x[1],'utf-8'),u'pending':x[2]} for x in results]

    def createNewSheet(self,sheetName):
        """
        create a new spreadsheet.
        """
        getsheet()
        sht = sheet.Sheet.getNew(sheetName.encode('utf-8'),self)
        id,stype,id_acc = getSdb().getSheetId(str(sht.getHandle()))
        return {u'sheetId':id,
                u'name':sheetName,
                u'type':stype, 
                u'numInvites':0,
                u'pendingInvites':0,
                u'sheetUid':unicode(str(sht.getHandle()),'utf-8')}

    def checkDeleteSheet(self,sheetId):
        """
        check if anyone is connected to a sheet that is about to be deleted.
        """
        # sheet id to sheet uid. hmm... this seems wrong
        sheetUid = getSdb().getSheetUid(sheetId)

        fdb = FDB.getInstance()
        if sheetUid in fdb:
            return fdb[sheetUid].NumConnections()

        return 0

    def deleteSheetByUID(self,sheetUID):
        id = getSdb().getSheetId(sheetUID)[0]
        self.deleteSheet(id)

    def deleteSheet(self,sheetId):

        getsheet()
        if sheetId not in self.ownedsheets:
            raise AccountAuthFailure(self,sheetId)

        sheetUid = getSdb().getSheetUid(sheetId)        
        fdb = FDB.getInstance()
        # kill any connected clients
        if sheetUid in fdb:
            fdb[sheetUid].KillAllClients()

        # remove the sheet from the cache.  Not that we
        # don't create a sheet instance here because that would force
        # an unnecessary sheet load
        shtI = sheet.Sheet.getInstanceFromCache(sheetUid)
        if shtI:
            shtI.delete()
        else:
            getSdb().deleteSheet(sheetUid)

    def sendEmailConfirm(self,rootURL):
        d = threads.deferToThread(self.emailConfirmInternal,rootURL)
        d.addErrback(dumper)
        return d

    def emailConfirmInternal(self,rootURL,emailaddr = None):
        """
        send an email to confirm the new account
        """
        if not emailaddr:
            emailaddr = self.userid
        
        webguid = base64.b16encode(self.stateGUID)
        verifyurl = rootURL.child('verifyaccount').child(webguid)
        newmail = invite.confirmEmail(emailaddr,verifyurl)
        invite.sendMailInternal(newmail,emailaddr)

    def accountVerified(self):
        """
        called after a user's email is verified.  This copies pending
        sheets to member table
        """
        getSdb().markAccountVerified(self)

    def removeMemberInvite(self,sheetId):
        """
        remove ourselves from a sheet that we were invited to.
        """
        self.removeInvitedSheet(sheetId)
        # return a deferred from the db layer
        return getSdb().removeInvitedSheet(self,sheetId)

    def modifyAccount(self,args,url):
        d = getSdb().modifyAccount(self,args)
        d.addCallback(self.afterModify,url)
        d.addErrback(dumper)
        return d

    def afterModify(self,args,url):
        if args.get('nick'):
            self.displayname = args['nick']
        if args.get('newemailtoken'):
            self.stateGUID = args['newemailtoken']
            self.state = 2
            threads.deferToThread(self.sendModifyMailInternal,url,args['newemail'])
        if args.get('tz'):
            if args.get('locale'):
                self.locale = NumblerLocale(args.get('locale'),args.get('tz'))
                del args['locale']
            else:
                self.locale = NumblerLocale(str(self.locale.locale),args.get('tz'))
            self.onLocaleChange()
        if args.get('locale'):
            if args.get('tz'):
                self.locale = NumblerLocale(args.get('locale'),args.get('tz'))
            else:
                self.locale = NumblerLocale(args.get('locale'),str(self.locale.tz))
            self.onLocaleChange()

    localeChangeMsg = u'The default language or timezone was changed for this sheet.  you may want to refresh your page.'
    def onLocaleChange(self):
        """
        handler that is called whenever the user's locale is changed.
        the idea here is to inform other connected users of a locale change
        """
        fdb = FDB.getInstance()
        for sheetId in self.ownedsheets:
            sheetUid = getSdb().getSheetUid(sheetId)
            if sheetUid in fdb:
                fdb[sheetUid].broadcastSystemMessage(self.localeChangeMsg)
            

    def sendModifyMailInternal(self,url,newemail):
        # send a mail to the new party
        self.emailConfirmInternal(url,newemail)
        # send a mail to the old party
        nm = invite.notifyEmailChange(newemail)
        invite.sendMailInternal(nm,self.userid)
    
        
def _test():
    import doctest
    doctest.testmod()

if __name__ == '__main__':
    _test()
